//  Copyright 2012 David Ekholm, jAlbum AB
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.package net.jalbum.editor;
package net.jalbum.injector;

import java.lang.reflect.Field;
import java.text.Annotation;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Parse the command line into the relevant annotated fields of the target object
 * @author david
 * @version 1.0
 * @see Switch
 * @see Argument
 */
public class ArgsInjector {

    private Object target;
    private Map<String, String> switches = new HashMap<String, String>();
    private List<String> arguments = new ArrayList<String>();
    private Map<Character, String> synonyms;
    private char MISSING_SYNONYM = '-';

    /**
     * Parse the command line into the relevant annotated fields of the target object
     * @param args The args string as passed to the main method
     * @param target Object with annotated fields to receive the arguments, mapped to the appropriate data types
     * @throws InjectorException If end user is specifying incorrect command line arguments,
     * for instance mandatory arguments are missing or datatypes don't match
     */
    public ArgsInjector(String[] args, Object target) throws InjectorException {
        this.target = target;

        Map<String, String> named = new HashMap<String, String>();
        List<String> unnamed = new ArrayList<String>();
        synonyms = getSynonyms(target);

        for (int i = 0; i < args.length; i++) {
            if (args[i].equals("-?") || args[i].equals("--help")) {
                throw new HelpException("Usage: " + usage() + "\n" + programDescription() + example() + "\n" + man());
            }
            if (args[i].charAt(0) == '-') {
                boolean negatedBoolean = args[i].startsWith("-no-");
                String name = negatedBoolean ? args[i].substring(4) : args[i].substring(1);
                name = toKey(name);
                if (isBooleanSwitch(name)) {
                    named.put(name, negatedBoolean ? "false" : "true");
                } else {
                    String value = i + 1 < args.length ? args[i + 1] : null;
                    named.put(name, value);
                    i++;
                }
            } else {
                unnamed.add(args[i]);
            }
        }

        switches = new HashMap<String, String>(named);
        arguments = new ArrayList<String>(unnamed);

        Class c = target.getClass();
        for (Field field : c.getDeclaredFields()) {
            String name = field.getName();
            field.setAccessible(true);
            Switch sw = field.getAnnotation(Switch.class);
            if (sw != null) {
                if (sw.name().length() > 0) {
                    name = sw.name();
                }
                if (named.containsKey(name)) {
                    setFieldValue(field, named.remove(name));
                } else if (sw.mandatory()) {
                    throw new InjectorException("Missing mandatory switch: " + name);
                }
            }
            Argument arg = field.getAnnotation(Argument.class);
            if (arg != null) {
                try {
                    Class type = field.getType();
                    if (type.isArray()) {
                        if (String[].class.isAssignableFrom(type)) {
                            try {
                                if (arg.mandatory() && unnamed.isEmpty()) {
                                    throw new InjectorException("Missing mandatory arguments: " + name);
                                }
                                field.set(target, unnamed.toArray(new String[0]));
                            } catch (IllegalAccessException ex) {
                                throw new RuntimeException("Cannot update field " + field.getName());
                            }
                            unnamed.clear();
                        } else {
                            throw new RuntimeException("Only allowed array type for argument field " + field.getName() + " is String[]. Found " + type);
                        }
                    } else {
                        setFieldValue(field, unnamed.remove(0));
                    }
                } catch (IndexOutOfBoundsException ex) {
                    if (arg.mandatory()) {
                        throw new InjectorException("Missing mandatory argument: " + name);
                    }
                }
            }
        }

        for (Field field : c.getDeclaredFields()) {
            String name = field.getName();
            field.setAccessible(true);
            Switch sw = field.getAnnotation(Switch.class);
            if (sw != null) {
                if (sw.name().length() > 0) {
                    name = sw.name();
                }
                if (switches.containsKey(name)) {
                    for (String d : sw.disallow().split("\\s*,\\s*")) {
                        if (d.length() > 0) {
                            if (switches.containsKey(d)) {
                                throw new InjectorException("'" + d + "' cannot be specified together with '" + name + "'");
                            }
                        }
                    }
                    for (String r : sw.require().split("\\s*,\\s*")) {
                        if (r.length() > 0 && !switches.containsKey(r)) {
                            throw new InjectorException("'" + name + "' requires '" + r + "' to be specified too");
                        }
                    }
                }
            }
        }

        if (named.size() > 0) {
            throw new InjectorException("Unknown switches: " + named.keySet());
        }
        if (unnamed.size() > 0) {
            throw new InjectorException("Unknown arguments: " + unnamed);
        }
    }

    /**
     * Replace proposed key with real name, if proposed key is a synonym
     */
    private String toKey(String proposed) {
        if (proposed.length() == 1) {
            String key = synonyms.get(Character.valueOf(proposed.charAt(0)));
            if (key != null) {
                return key;
            }
        }
        return proposed;
    }

    /**
     * @return a Map of all switches passed, converted to normalized form (booleans have true/false values, all aliases mapped to their real names)
     */
    public Map<String, String> getSwitches() {
        return switches;
    }

    /**
     * @return List of all unnamed arguments passed
     */
    public List<String> getArguments() {
        return arguments;
    }

    /**
     * Print usage string for the target object
     */
    public String usage() {
        StringBuilder buf = new StringBuilder();
        buf.append(command());

        Class c = target.getClass();
        for (Field field : c.getDeclaredFields()) {
            String name = field.getName();
            field.setAccessible(true);
            Switch sw = field.getAnnotation(Switch.class);
            if (sw != null) {
                if (sw.name().length() > 0) {
                    name = sw.name();
                }
                if (!sw.mandatory()) {
                    buf.append('[');
                }
                buf.append('-');
                buf.append(name);
                if (!sw.mandatory()) {
                    buf.append(']');
                }
                buf.append(' ');
            }
        }
        for (Field field : c.getDeclaredFields()) {
            String name = field.getName();
            field.setAccessible(true);
            Argument arg = field.getAnnotation(Argument.class);
            if (arg != null) {
                if (!arg.mandatory()) {
                    buf.append('[');
                }
                buf.append(name);
                if (!arg.mandatory()) {
                    buf.append(']');
                }
                if (field.getType().isArray()) {
                    buf.append("...");
                }
                buf.append(' ');
            }
        }
        return buf.toString();
    }

    /**
     * Describe each command line swich and argument, one line per argument
     */
    public String man() {
        StringBuilder buf = new StringBuilder();
        Class c = target.getClass();
        buf.append("Switches:\n");
        for (Field field : c.getDeclaredFields()) {
            String name = field.getName();
            field.setAccessible(true);
            Switch sw = field.getAnnotation(Switch.class);
            if (sw != null) {
                if (sw.name().length() > 0) {
                    name = sw.name();
                }
                buf.append("\t-").append(name);
                if (sw.alias() != MISSING_SYNONYM) {
                    buf.append(", -").append(sw.alias());
                }

                Class type = field.getType();
                String typeName = type.getSimpleName();
                if ("boolean".equals(typeName) || Boolean.class.isAssignableFrom(type)) {
                } else {
                    buf.append(' ').append(field.getType().getSimpleName());
                }
                buf.append("\n\t\t").append(sw.description()).append('\n');
            }
        }
        buf.append("Arguments:\n");
        for (Field field : c.getDeclaredFields()) {
            String name = field.getName();
            field.setAccessible(true);
            Argument arg = field.getAnnotation(Argument.class);
            if (arg != null) {
                buf.append("\t");
                buf.append(name);
                buf.append(' ');
                buf.append(field.getType().getSimpleName());
                buf.append("\n\t\t");
                buf.append(arg.description());
                buf.append('\n');
            }
        }
        return buf.toString();
    }

    private Object toType(String svalue, Class type) throws InjectorException {
        String typeName = type.getSimpleName();
        Object value;
        try {
            if (String.class.isAssignableFrom(type)) {
                value = svalue;
            } else if ("int".equals(typeName) || Integer.class.isAssignableFrom(type)) {
                value = Integer.valueOf(svalue);
            } else if ("short".equals(typeName) || Short.class.isAssignableFrom(type)) {
                value = Short.valueOf(svalue);
            } else if ("long".equals(typeName) || Long.class.isAssignableFrom(type)) {
                value = Long.valueOf(svalue);
            } else if ("boolean".equals(typeName) || Boolean.class.isAssignableFrom(type)) {
                value = Boolean.valueOf(svalue);
            } else if ("byte".equals(typeName) || Byte.class.isAssignableFrom(type)) {
                value = Byte.valueOf(svalue);
            } else if ("char".equals(typeName) || Character.class.isAssignableFrom(type)) {
                value = Character.valueOf(svalue.charAt(0));
            } else if ("float".equals(typeName) || Float.class.isAssignableFrom(type)) {
                value = Float.valueOf(svalue);
            } else if ("double".equals(typeName) || Double.class.isAssignableFrom(type)) {
                value = Double.valueOf(svalue);
            } else {
                throw new InjectorException("Can't interpret \"" + svalue + "\"");
            }
            return value;
        } catch (NumberFormatException ex) {
            throw new InjectorException("Can't interpret \"" + svalue + "\"");
        }
    }

    private void setFieldValue(Field field, String svalue) throws InjectorException {
        try {
            Object value = toType(svalue, field.getType());
            field.set(target, value);
        } catch (IllegalAccessException ex) {
            throw new RuntimeException("Can't update field \"" + field.getName() + "\"");
        }
    }

    private boolean isBooleanSwitch(String s) throws InjectorException {
        Class c = target.getClass();
        for (Field field : c.getDeclaredFields()) {
            field.setAccessible(true);
            Switch sw = field.getAnnotation(Switch.class);
            if (sw != null) {
                String name = sw.name().length() > 0 ? sw.name() : field.getName();
                if (name.equals(s)) {
                    Class type = field.getType();
                    String typeName = type.getName();
                    return "boolean".equals(typeName) || Boolean.class.isAssignableFrom(type);
                }
            }
        }
        throw new InjectorException("No such switch: " + s);
    }

    private Map<Character, String> getSynonyms(Object target) {
        Map<Character, String> synonyms = new HashMap<Character, String>();
        Set<String> allKeys = new HashSet<String>();
        boolean annotated = false;
        Class c = target.getClass();
        for (Field field : c.getDeclaredFields()) {
            field.setAccessible(true);
            Switch sw = field.getAnnotation(Switch.class);
            if (sw != null) {
                annotated = true;
                String name = sw.name().length() > 0 ? sw.name() : field.getName();
                registerName(name, allKeys);
                if (sw.alias() != MISSING_SYNONYM) {
                    registerName(String.valueOf(sw.alias()), allKeys);
                    synonyms.put(sw.alias(), name);
                }
            }
            if (field.isAnnotationPresent(Argument.class)) {
                annotated = true;
            }
        }
        if (!annotated) {
            throw new RuntimeException("Target object's member variables not annotated with either @Switch or @Argument");
        }
        return synonyms;
    }

    private void registerName(String key, Set<String> allKeys) {
        if (allKeys.contains(key)) {
            throw new RuntimeException("Ambigious use of switch '" + key + "'");
        }
        allKeys.add(key);
    }

    private String programDescription() {
        Program program = target.getClass().getAnnotation(Program.class);
        if (program != null && program.description().length() > 0) {
            return program.description() + "\n";
        }
        return "";
    }

    private String command() {
        Program program = target.getClass().getAnnotation(Program.class);
        if (program != null) {
            return program.command() + " ";
        }
        return "";
    }

    private String example() {
        Program program = target.getClass().getAnnotation(Program.class);
        if (program != null && program.example().length() > 0) {
            return "Example: " + command() + program.example() + "\n";
        }
        return "";
    }
}
